fix(common): resolve a trailing ".." in NSSystemPath::ShortenPath - #137
fix(common): resolve a trailing ".." in NSSystemPath::ShortenPath#137Hathor875 wants to merge 1 commit into
Conversation
ShortenPath handles a ".." token in two places: inside the loop, when it hits a
separator, and after the loop, for the final token. The two branches asked the
opposite question — the post-loop one tested `L".." == arStack.top()` where the
in-loop one tests `!=` — so they swapped the two cases:
- top of stack is a normal segment: should pop (the ".." cancels it), but the
post-loop branch pushed instead, leaving "a/.." unresolved
- top of stack is itself "..": should push (nothing left to cancel), but the
post-loop branch popped, so "../.." collapsed to an empty string
Only paths not ending in a separator took that branch, which is why "a/../"
worked and "a/.." did not.
All eight callers use the result as a containment check — the ZipSlip guard in
OfficeUtils, the "../" rejection in EpubFile, and starts_with(root) checks in
HtmlFile2, OFDFile and the SVG image loader — over paths taken from a document.
The empty-string case weakened the first two: an empty value passes a "starts
with ../" test. Measured against Combine(root, rel), exactly one verdict changes:
rel=".." resolved to "/data/.." and passed starts_with("/data") on a textual
prefix; it now resolves to "" and is rejected.
Also guards wsNewPath.pop_back(), which ran on an empty string when the assembly
loop breaks on the first entry (bRemoveExternalPath with a leading ".."), and
segfaulted.
Adds DesktopEditor/common/test — the first unit tests for this module. Pure
functions, no fixtures: 12 cases covering the three defects and the behaviour
that must not change. Verified by reverting the fix (2 failures plus a SEGFAULT)
and restoring it (12/12); the other built CTest suites still pass.
ShortenPath(L"/") returns an empty string rather than "/". Unchanged here and
not pinned by a test — it looks wrong but is outside this fix.
Signed-off-by: Krzysztof Cieślik <132496025+Hathor875@users.noreply.github.com>
Assisted-by: Claude Code:claude-opus-5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chrip
left a comment
There was a problem hiding this comment.
Summary
The diagnosis is right, the two-line fix is right, and the tests are the kind this repo
should have more of. I reproduced everything independently: extracted ShortenPath into a
standalone harness in both its origin/main and post-PR forms and ran all 12 pinned cases.
Post-fix 12/12 pass; pre-fix exactly 2 fail (a/.. → a/.., ../.. → "") and
ShortenPath(L"..", true) dies under ASan with SEGV on unknown address ... caused by a WRITE memory access inside basic_string(basic_string&&) — the pop_back() on an empty
string. That matches the PR's "2 failures plus a SEGFAULT" claim precisely. The eight-caller
inventory is exact, and every one of them really is a containment check.
One substantive item before merge. The impact section measures only the starts_with(root)
form of the guard and reports "exactly one verdict flips". I swept the same change through
all the caller predicates and the three "../"-prefix guards flip 16 inputs in the
deny→allow direction, because paths of the shape ../X/.. now resolve to exactly ".." —
two characters, so a check requiring size() > 3 and a "../" prefix no longer fires. I
chased each one down to the actual file operation and none of them escape (details below), so
this is not a blocker, but the PR's central justification is a security-impact analysis and
right now it is incomplete in the one direction a reader cares about.
CI has now run — the gated workflows were released on 2026-08-27 — and it backs the PR up.
The Linux leg is green and path_test genuinely compiled, linked and executed under CTest:
Linking CXX executable path_test/path_test, then 9/9 Test #9: path_test ... Passed, with
the run ending 100% tests passed, 0 tests failed out of 8 (the shortfall is ooxml_test
being Not Run (Disabled), pre-existing). WASM is green too. The Windows leg is red, but for
a reason that has nothing to do with this PR — see below.
Verification
| Claim / Item | Reality | Status |
|---|---|---|
The two ".." branches ask opposite questions |
Confirmed. Path.cpp:202 (in-loop) tests L".." != arStack.top(); Path.cpp:216 (post-loop) tested L".." == arStack.top(). The in-loop form is the correct one — a ".." cancels the top only when the top is a real segment. |
✓ |
| Only paths without a trailing separator reach the post-loop branch | Confirmed by construction: the in-loop branch fires on / or \, so a trailing separator flushes the token before the loop ends. Explains a/../ working and a/.. not. |
✓ |
pop_back() ran on an empty string; UB, segfault in practice |
Confirmed. while (!arStack.empty()) breaks on the first iteration when bRemoveExternalPath && ".." == arStack.top(), leaving wsNewPath empty. Reproduced under -fsanitize=address: SEGV ... WRITE memory access in __memcpy_generic via the std::wstring move ctor. Triggered by ShortenPath(L"..", true) and ShortenPath(L"../", true) — the crash is not limited to the no-trailing-separator case, so the guard is the right shape. |
✓ |
| Exactly 8 callers, all containment checks | Exact. grep gives OOXMLTags.cpp:291 + 985, ZipUtilsCP.cpp:294, htmlfile2.cpp:1557 + 4520, CImage.cpp:79, CEpubFile.cpp:165, OFDFile/src/Utils/Utils.h:161 = 8, plus the decl/def. Read all eight: three use a "../"-prefix test, five use starts_with(root). |
✓ |
An empty result passes a "starts with ../" test |
Confirmed for all three prefix guards (ZipUtilsCP.cpp:296 and CEpubFile.cpp:167 need size() > 3; CImage.cpp:85 and the CanUseThisPath fallback need size() >= 3). And starts_with(root) on "" is false → deny, so those five fail safe. |
✓ |
Combine(root, rel), rel="..": /data/.. → "", now rejected |
Confirmed. Pre-fix /data/.. (passes starts_with("/data") on a textual prefix); post-fix "" (rejected). |
✓ |
| "exactly one verdict flips; five other cases unchanged" | True for the sample measured, but the sample is only the starts_with(root) callers. Across the "../"-prefix callers the change is 28 allow→deny and 16 deny→allow. See |
|
| 12 test cases: 3 reproduce defects, 9 pin existing behaviour | Exact count, and the split matches. All 12 expectations verified independently against both code versions. | ✓ |
Guard returns "" rather than "/" for an absolute input |
Consistent with the pre-existing if (arStack.empty()) return std::wstring(); two lines above, which already does exactly this (that is why ShortenPath(L"/") → ""). Placing the new guard before the #if !defined(_WIN32) prefix block keeps the two paths in agreement rather than inventing a third convention. Right call. |
✓ |
NSSystemPath is part of kernel |
Common/CMakeLists.txt:40 compiles DesktopEditor/common/Path.cpp. The CMakeLists comment is accurate and LIBS kernel is sufficient. |
✓ |
add_core_gtest usage |
Matches the signature at common.cmake:397. GTEST_MAIN is required (no main() in path.cpp); no fixtures, so no WORKING_DIRECTORY. CORE_ROOT_DIR = ../../.. is correct for DesktopEditor/common/test. Mirrors OfficeUtils/tests/CMakeLists.txt. |
✓ |
KERNEL_DECL resolves to import in the test |
Correct — the test does not define KERNEL_USE_DYNAMIC_LIBRARY_BUILDING, which Common/CMakeLists.txt sets PRIVATE on kernel. Same as every other consumer. |
✓ |
| Adds the same directory as #136 | Confirmed, and #136 is still OPEN. Both create DesktopEditor/common/test/CMakeLists.txt (project(path_test) vs project(directory_test)) and both add a line at root CMakeLists.txt:42. Real conflict, correctly disclosed. |
✓ |
| CI validated the change | Yes, on Linux and WASM. build.yml is the only leg that sets -DEO_BUILD_TESTS=ON and runs ctest; it built path_test/path.cpp.o, linked the binary and reported Test #9: path_test ... Passed alongside the 8 existing suites. Note ctest prints case output only on failure, so the log does not enumerate the 12 cases — it evidences build + run + exit 0. |
✓ |
| Windows leg failure is unrelated | Confirmed. Build (Windows x64) dies in CMake Configure, before any core source is compiled: Common/3dParty/build_3rdparty.py failed! from depot_tools' gclient sync → git_cache.py:217 GetCachePath() → FileNotFoundError: [WinError 2] (no git for depot_tools' vpython). Path.cpp is never compiled and DesktopEditor/common/test never configured, so this PR cannot cause it. Same failing step on every recent branch — including fix/doctrenderer-compile-guard (#138), which merged with it red ×3. Last green Windows run was 2026-08-15, on #132's branch. |
✓ |
| DCO sign-off | Present in the commit; DCO check SUCCESS. |
✓ |
| AI disclosure | Assisted-by: Claude Code:claude-opus-5 in both PR body and commit trailer, plus Co-Authored-By:. Compliant with the org AI policy. |
✓ |
Issues & Suggestions
⚠️ Major
-
The impact analysis measured one of the two guard styles, and the unmeasured one moves
the other way. The PR says "Measured overCombine(root, rel), exactly one verdict
flips". That covers the fivestarts_with(root)callers. It does not cover the three that
test for a"../"prefix —ZipUtilsCP.cpp:296,CEpubFile.cpp:167,CImage.cpp:85(and
thewsCorePath.empty()fallback in bothCanUseThisPathimplementations).Sweeping every path over the segment alphabet
{a, b, ., ..}up to length 4, with and
without a leading/trailing separator, through those predicates gives 28 allow→deny and
16 deny→allow. Every one of the 16 has the same shape:../X/..(plus.-noise
variants like.././a/..,../a/./..) previously resolved to../X/..and was rejected;
it now correctly resolves to".."— which is 2 characters, sosize() > 3fails and
substr(0, 3) == "../"never matches.".."is an escape, and the guards no longer see
it.I chased all of them to the actual file operation and none escape, so I am not calling
this blocking:ZipUtilsCP.cpp:outputis built from the rawfilenameW(ZipUtilsCP.cpp:267), so
the guard is the only thing in the way — but a resolved form of".."means the write
target ends in/.., andCreateFileWon a directory fails.CEpubFile.cpp:173:sFile == ".."yields<tmp>/<content>/.., handed to
ConvertHTML2OOXMLas a file to read. A directory; the read fails.CImage.cpp:88:<workdir>/..thenCFileBinary::Exists→ false.
What I would like before merge is small: pin the behaviour with one more test so nobody has
to re-derive this, and correct the claim.// ../X/.. resolves to a bare "..", which is still outside the base. The "../"-prefix // guards in ZipUtilsCP, CEpubFile and CImage require >= 3 characters and so do not // catch it; only the starts_with(root) callers reject it. Pinned so the gap is visible. TEST(ShortenPath, collapses_a_parent_chain_to_a_bare_parent) { EXPECT_EQ(L"..", NSSystemPath::ShortenPath(L"../b/..")); }
and reword the impact paragraph along the lines of: "Measured over
Combine(root, rel)
for the fivestarts_with(root)callers, exactly one verdict flips. The three
"../"-prefix callers tighten in 28 cases and loosen in 16, all of the form../X/..→
"..", which theirsize() >= 3test misses; in each case the resulting path names a
directory and the subsequent file operation fails, so no traversal materialises. Tightening
those guards to also reject".."and""is left to a follow-up."The follow-up itself is genuinely out of scope here — worth noting that #117 already adds
if (wsNormalizedFilename.empty()) return UNZ_INTERNALERROR;toZipUtilsCP, so half of
it lands there if that PR merges. The two PRs don't conflict (different files) but #137
changes the semantics #117 depends on, so whichever merges second wants a re-read. -
The red Windows check is not yours — but it should be named, not merged past silently.
Build (Windows x64)fails inCMake Configure, in depot_tools' V8 sync
(git_cache.py:217 GetCachePath()→FileNotFoundError: [WinError 2]), beforePath.cpp
is compiled orDesktopEditor/common/testis configured. Nothing in this diff can reach
it, and it reproduces on every recent branch. #132 (fix(v8): ensure a git shim exists in depot_tools on Windows) is the fix, and the only PR with a green Windows leg since
2026-08-15; it was amended on 2026-08-28 and is now DCO-green with its builds re-running.
That is a repo-health item rather than a #137 item, but until it lands this PR merges
without Windows validation.
ℹ️ Minor
-
keeps_an_absolute_pathis platform-dependent.ShortenPathonly re-attaches the
leading/under#if !defined(_WIN32) && !defined(_WIN64)(Path.cpp:249), so
EXPECT_EQ(L"/tmp/evil.txt", ...)atpath.cpp:52returnstmp/evil.txton Windows.
Harmless today —build-windows.ymlnever sets-DEO_BUILD_TESTS=ON, so the suite isn't
built there — but it will bite whoever turns Windows tests on. Either guard the expectation
with the same#if, or leave a one-line comment saying the case is POSIX-only. -
No license header on the two new files. Worth stating plainly what is and isn't a
problem here. The good part:path.cppdoes not carry a copy-pasted
(c) Copyright Ascensio System SIA 2010-2019block, which is the mistake I keep seeing on
new files in these forks — it would misattribute fork-authored work and drag in the AGPL
§7(a) and CC-BY-SA clauses that are specifically ONLYOFFICE's. Avoiding that was right.The gap is that there is no header at all. In this repo that is consistent with every
precedent:corehas zeroSPDX-License-Identifierlines anywhere, and all three
fork-authored test CMakeLists (OOXML/test,Common/cfcpp/test,OdfFile/Test/test_odf)
andOfficeUtils/tests/main.cppare equally bare. So this is a convention the repo has
never set, not something this PR broke — which is exactly why it's worth setting once,
across #136 and #137 together, rather than PR by PR. The form the org already uses in its
own.githubrepo:/* * SPDX-FileCopyrightText: 2026 Euro-Office contributors * SPDX-License-Identifier: AGPL-3.0-or-later */(
#for the CMakeLists.) Files that genuinely derive from Ascensio code keep the upstream
notice and gain the Euro-Office line beneath it; only from-scratch files like these two get
the Euro-Office header alone. Happy for this to be a separate PR if you'd rather not
reopen #136/#137 for it. -
TESTING.mdisn't updated. That file is the register of CTest suites — it opens with
"13 are real GoogleTest suites" and itsMigration status → Donelist enumerates every one.
path_testmakes 14 and is the first suite that isn't a qmake migration at all, so it
doesn't fit the existing headings. A short "New suites (not from qmake)" bullet would keep
the doc honest. #136 has the same gap; one entry covering both would do. -
No linked issue. "Own find, not from a report" is fine, and the org's issue-first rule
is aimed at features rather than drive-by bug fixes. But two things in this PR deserve to
outlive it:ShortenPath(L"/")returning""instead of/, and thesize() >= 3gap in
the three prefix guards. An issue for each would stop them being rediscovered from scratch.
💡 Suggestions
-
Concrete resolution for the #136 collision. Rather than merging the two CMakeLists by
hand into something ad hoc, whichever lands second can make the directory hold both
targets — that scales as moreDesktopEditor/commontests arrive:project(common_test) ... add_core_gtest(NAME path_test SOURCES path.cpp LIBS kernel GTEST_MAIN) add_core_gtest(NAME directory_test SOURCES directory.cpp LIBS kernel GTEST_MAIN)
with a single root line
add_subdirectory( "${CORE_ROOT_DIR}/DesktopEditor/common/test" common_test ).
That also wants #136'smain.cpprenamed todirectory.cpp, matching this PR's choice of
path.cpp—main.cppis a poor name for a file with nomain()(GTEST_MAINgenerates it). -
The
bRemoveExternalPathguard could arguablyreturn L"/"for an absolute input instead
of"", since/..is/. I'd leave it as is — matching the existingarStack.empty()
return keeps one convention instead of two, and""fails safe understarts_with(root).
Noting it only so the choice reads as deliberate.
Code quality & conventions
- Commit:
fix(common): resolve a trailing ".." in NSSystemPath::ShortenPath—
Conventional Commits, correct type and scope. The body is genuinely good: it states the
defect, the mechanism, the caller impact, the verification method, and the one thing left
unfixed. That is the standard I'd like other PRs here to hit. - DCO:
Signed-off-by: Krzysztof Cieślikpresent, check green. - AI disclosure: compliant — PR body section plus
Assisted-by: Claude Code:claude-opus-5
andCo-Authored-By:trailers. - Tests: present, and they are the right tests — pure-function cases with no fixtures, 3
reproducing the defects and 9 pinning behaviour that must not move. Verified both ways by
the author and independently by me. This is what a behavioural fix in this repo should look
like. - Scope: focused. One function, two changes, plus the test scaffolding those changes need.
- Comments: the block at
Path.cpp:240-242and the header comment inpath.cppboth
explain why rather than restating the code. Kept.
Verdict
Comment — the fix is correct, minimal, and properly tested; I verified the defect, the
crash, the caller inventory and all 12 expectations independently, and they all hold, and CI
now confirms path_test builds and passes under CTest. One ask before merge: extend the
impact analysis to the "../"-prefix guards — it currently reports a net tightening while 16
inputs loosen, none of them exploitable, which is one extra test and a reworded paragraph.
The red Windows leg is the pre-existing depot_tools breakage (#132), not this PR. The
license-header and TESTING.md points are conventions to settle across #136 and #137
together, not reasons to hold this PR.
Assisted-by: ClaudeCode:claude-opus-5
#137 — path normalization
Own find, not from a report.
Bug
NSSystemPath::ShortenPathhandles a trailing..in a post-loop branch thathad the operator inverted (
==where the in-loop branch uses!=), so the twocases were swapped:
a/..unresolved..../..→""Only paths without a trailing separator reach that branch — hence
a/../workedand
a/..didn't.Same function:
pop_back()on an empty string when the assembly loop breaks onthe first entry. UB, segfault in practice.
Impact
All 8 callers use the result as a containment check on document-supplied paths
(ZipSlip guard in
ZipUtilsCP.cpp:294,../rejection inCEpubFile.cpp:165,starts_with(root)in HtmlFile2 ×2, OOXMLTags ×2, OFDFile, CImage). An emptyresult passes a "starts with
../" test.Measured over
Combine(root, rel), exactly one verdict flips:..gave/data/.., which passedstarts_with("/data")textually while pointing onelevel above. Now rejected. Five other cases unchanged.
Change
DesktopEditor/common/Path.cpp, +7/−1 — one operator on line 216, one guardbefore
pop_back().Tests
New
DesktopEditor/common/test, first unit tests for this module. 12 cases:3 reproduce the defects, 9 pin behaviour that must not change. Verified both
ways (fix reverted → 2 failures plus a SEGFAULT in
does_not_crash_on_bare_parent_when_removing_external_path; fix in place →12/12), 3 identical runs. Other CTest suites still pass 6/6, including
officeutils_test, which extracts archives throughShortenPath.Notes
ShortenPath(L"/")returns""rather than/. Looks wrong, out of scope,deliberately not pinned.
reading the code plus the measurement, not an executed suite.
DesktopEditor/common/test/, same directory as fix(common): list symlinked files when scanning directories #136. Whichever landssecond needs the two
CMakeLists.txtmerged by hand.AI assistance
Prepared with AI assistance (Claude Code,
claude-opus-5); commit carries anAssisted-by:trailer.